データ取得から見るGraphQL、Concurrent Mode、Server Components
tl;dr
Render-as-You-FetchとReact Concurrent Renderingは不可分ではないので可能ならいつでも使っちゃっていいんじゃないでしょうか
React Concurrent RenderingのAPIはRender-as-You-Fetchパターンと相性が良くなるように設計されてる
Render-as-You-FetchをGraphQLなしでもうまく扱う方法としてReact Server Componentsがありそう
RelayのHooksがReact Concurrent Renderingを待たずに正式リリースされて、いいのか?と思いつつ考えて書いてみているtosuke.icon
とりあえずReact公式ドキュメントにまとめられているデータ取得方式をまとめてみます
例として、こういう感じのAPIがあって
code:api.ts
// ブログ記事みたいなもの
type Story = {
title: string
body: string
// 関連記事一覧
relatedStoryIds: string[]
}
function fetchStory(id: string): Promise<Story> { ... }
こういう感じのPresenter Componentが定義されているとします
code:presenters.tsx
// 記事詳細ページ
const StoryDetails: React.VFC<{
title: string
body: string
relatedStoryItems: React.ReactNode
}> = ...
const StoryDetailsPlaceholder: React.VFC = ...
// 記事のリッチなリンクみたいなもの
const StoryItem: React.VFC<{ title: string }> = ...
const StoryItemPlaceholder: React.VFC = ...
Fetch-on-Render
いつもuseEffectでやってるような、データが必要になったらその場でデータ取得を始める方法です。
swr や react-query のようなデータ取得ライブラリもこれに含まれます
データ競合はとりあえず考えないことにして、例を書いてみます。
code:containers-fetch-on-render.tsx
const StoryItemContainer: React.VFC<{ id: string }> = ({ id }) => {
const story, setStory = useState<Story>()
useEffect(() => {
fetchStory(id).then(setStory)
}, id)
if(story == null) return <StoryItemPlaceholder />
return <StoryItem title={story.title} />
}
const StoryDetailsContainer: React.VFC<{ id: string }> = ({ id }) => {
// StoryItemContainerと同じなので省略
if(story == null) return <StoryPlaceholder />
return (
<StoryDetails
title={story.title}
body={story.body}
relatedStoryItems={
story.relatedStoryIds.map(
storyId => (
<StoryItemContainer id={storyId} />
)
)
}
/>
)
}
この方法は「コンポーネントが必要なデータを得たらすぐに描画できる」というところが良いです。待たされる時間が最小限で済みます。逆に「コンポーネント階層がデータの取得順を決めてしまっている」という欠点があります。これだけだと謎なので例を挙げてみます。
さっきの例が、仕様変更によって<StoryDetails />が関連記事一覧をファーストビューに置くことになったとします。
この実装だと記事の取得が完了した後にすぐに記事の描画が行われます。結果としてユーザーは必ず関連記事一覧のプレースホルダを目にすることになります。体験が悪い。
これを避けるために記事の描画タイミングを関連記事一覧の取得タイミングに揃えようと思います。つまりコンポーネント階層を変えて、<StoryDetailsContainer/>が<StoryItem/>を直接描画するように変更します。
code:containers-fetch-on-render-2.tsx
const StoryDetailsContainer: React.VFC<{ id: string }> = ({ id }) => {
const story, setStory = useState<Story>()
const relatedStories, setRelatedStories = useState<Story[]>()
useEffect(() => {
async function fetchData() {
setStory(await fetchStory(id))
setRelatedStories(
await Promise.all(
storyData.relatedStoryIds.map(fetchStory)
)
)
}
fetchData()
}, id)
if(story == null || relatedStories == null) return <StoryPlaceholder />
return (
<StoryDetails
title={story.title}
body={story.body}
relatedStoryItems={
relatedStories.map(
s => (
<StoryItem title={s.title} />
)
)
}
/>
)
}
見比べてみるとわかりますが、かなり大きく変更されたことがわかります。つらい。
ここにさらに「ファーストビューでの関連記事は最初の5個しかいらない」というような要件が入ったらどうなるでしょう?考えたくもないですね。
Fetch-then-Render
Fetch-on-Renderで起きた問題は、ページ上位でデータを全て取得してしまうことで緩和することができます。つまり、「データ取得順によって問題が起きるなら、全部一気に取得してしまえ!」ということです。イメージとしてはNext.jsが近いです。
code:containers-fetch-then-render.tsx
type StoryDetailsData = {
story: Story
relatedStories: Story[]
}
// ルータに呼ばれるデータ取得関数
async function fetchStoryDetails(id: string): Promise<StoryDetailsData> {
const story = await fetchStory(id);
const relatedStories = await Promise.all(
story.relatedStoryIds.map(fetchStory)
)
return {
story,
relatedStories
}
}
// ルータがdataを渡す
const StoryDetailsContainer: React.VFC<{ data?: StoryDetailsData }> = ({ data }) => {
if (data == null) return <StoryDetails />
const { story, relatedStories } = data
return (
<StoryDetails
title={story.title}
body={story.body}
relatedStoryItems={
relatedStories.map(
s => (
<StoryItem title={s.title} />
)
)
}
/>
)
}
この方法にはデータの依存関係の問題の解決以外にも、データ取得の責任がルータに移ったことでルータが柔軟な制御ができるという利点があります。つまり
Next.jsが既にやっているように、データ取得が完了するまでページ遷移を遅らせるということができます
SSRへの対応も楽です
しかし
必要なデータをまとめてクエリするのは難しいです
GraphQLならばFragmentで楽できます
要求しているデータが来るまで描画を開始できず、場合によってはFetch-on-Renderを併用する必要が出てきます。結局データの依存関係を考える必要がありそうです
Render-as-You-Fetch
ここで、Fetch-on-RenderとFetch-then-Renderの利点がいい感じに組み合わされたデータ取得の方法を考えてみます。つまり
細かい単位で表示順序を制御できて
データの依存関係とコンポーネント階層を分離できる
Render-as-You-Fetchはそんな感じのやつです。
code:resource.ts
interface Resource<T> extends Promise<T> {
read(): T
then<U>(f: (value: T) => U | Promise<U>): Resource<U>
}
function Resource<T>(create: () => Promise<T>): Resource<T> { ... }
code:containers-render-as-you-fetch.tsx
const StoryItemContainer: React.VFC<{ story: Resource<Story> }> = (props) => {
const story = props.story.read()
return <StoryItem title={story.title} />
}
type StoryDetailsData = {
story: Story
relatedStories: Resource<Story>[]
}
function fetchStoryDetails({ id }: { id: string }): Resource<StoryDetailsData> {
return Resource(() => fetchStory(id)).then(
story => ({
story,
relatedStories: story.relatedStoryIds.map(
storyId => Resource(() => fetchStory(storyId))
)
})
)
}
const StoryDetailsContainer: React.VFC<{ data: Resource<StoryDetailsData> }> = ({ data }) => {
const { story, relatedStories } = data.read()
return (
<StoryDetails
title={story.title}
body={story.body}
relatedStoryItems={
relatedStories.map(
s => (
<Suspense fallback={<StoryItemPlaceholder />}>
<StoryItemContainer story={s} />
</Suspense>
)
)
}
/>
)
何が起きてるのか完全に意味不明だと思います。PromiseじゃなくてResourceとかいうよくわからないものが使われてますし。
ResourceはPromiseに状態管理を付けたものです。状態管理と言ってもReduxやRecoilのような複雑なものではなく、Promiseの内部状態を管理して取得可能な場合に値を同期的に取得できるようにするものです。
.read()すると内部のPromiseが
待機状態(pending)な場合、コンポーネントの最も近い祖先の<Suspense />がフォールバックを表示し、待機状態が終わったときに再描画を試みます
完了状態(fulfilled)な場合、値を返します
拒絶状態(rejected)な場合、エラーをthrowします
結果として挙動はFetch-on-Renderのものに似ていて、記事が表示された後に関連記事一覧が表示されます。
ここで、Fetch-on-Renderのときのような仕様変更を考えます。記事の表示タイミングと関連記事一覧の表示タイミングを揃えたいわけですが、実は<Suepense />を外すだけです。
code:containers-render-as-you-fetch-2.tsx
...
relatedStoryItems={
relatedStories.map(
s => (
<StoryItemContainer story={s} /> // <-ここの Suspense を外しただけ
)
)
}
...
調子に乗って関連記事の最初5個だけを記事と揃えるようにしてみましょう。
code:containers-render-as-you-fetch-3.tsx
...
relatedStoryItems={
relatedStories.map(
(s, i) => {
const node = <StoryItemContainer story={s} />
if(i < 5) return node // 最初5個は Suspense なしで描画
return <Suspense fallback={<StoryItemPlaceholder/>}>{node}</Suspense>
}
)
}
...
非常に簡単にできてしまいました。また、次々に記事が表示されると見た目が悪いので、残りは取得が全部終わったら表示するようにしましょう。
code:containers-render-as-you-fetch-4.tsx
...
relatedStoryItems={
<SuspenseList revealOrder="together">
{relatedStories.map(
(s, i) => {
const node = <StoryItemContainer story={s} />
if(i < 5) return node // 最初5個は Suspense なしで描画
return <Suspense fallback={<StoryItemPlaceholder/>}>{node}</Suspense>
}
)}
</SuspenseList>
}
...
はい。<SuspenseList />を追加しただけです。<SuspenseList />は<Suspense />をまとめて制御するプリミティブです。revealOrder="togetherの場合、配下の<Suspense />全てが完了状態になるまで全てを待機状態にします。ここでは細かい説明をしないので、公式ドキュメントを見ることをおすすめします。
このように、<Suspense />と<SuspenseList />を組み合わせるだけでどのようにデータを待つのか決めることができてしまいます。
ここで、「Fetch-then-Renderで実現できていたページ遷移の遅延はどうなったの?Render-as-You-Fetchではできないの?」と思うかもしれません。React Concurrent RenderingならuseTransitionを使うことで可能です。
TODO: 例
どのようにデータをまとめて取得するか
Fetch-then-Renderの例でもRender-as-You-Fetchの例でも、最終的にコンポーネントの要求するデータはfetchStoryDetailという関数にまとめられていました。しかし、この手法には限界があります。
使うデータが多くなってくると、それらを全部まとめるのは難しくなる
データ同士に依存関係がある(relatedStoryIdsのような)ケースだと、ラウンドトリップが気になる
GraphQL
このような問題を解決する手段として有名なものにGraphQLがあります。Fragmentを使ってコンポーネントの要求するデータを表現し、それをまとめて1つのQueryを作ることができます。データの依存関係はサーバ上で解決されるので、ラウンドトリップの心配もありません。
しかし、このようにまとめられたGraphQLクエリは全てのデータが取得できるまで返ってこないので、挙動としては基本的にFetch-then-Renderと同じになります。
クエリの一部がキャッシュにマッチした場合はその限りではありません。https://relay.dev/docs/guided-tour/reusing-cached-data/rendering-partially-cached-data/ などを見るとよいです。
クエリを分割して実行することである程度対処できますが、クエリが複雑な場合分割は面倒になりますし、複数のクエリを実行するのはサーバ負荷の上昇も気になります。この問題を解決するために、@deferや@streamのようなディレクティブを使い、サーバにレスポンスを分割して返すことを許すようにするという仕様が提案され、実装が始まっています。
React Server Components
GraphQLでRender-as-You-Fetchに対応したデータ取得がうまくいきそうというのはわかりました。では今RESTを使っている人はどうなるのでしょうか?
ラウンドトリップの問題に対処する手段として、既にBFFを使うという方法が知られています。つまりクライアントと(リソースを持っている)サーバの間にもう1つサーバを置いて、その上でデータの依存関係を解決してしまおうという方法です。
確かにBFFで問題を解決することはできます。しかしコンポーネントの要求するデータをうまく表現し、まとめるのは難しそうです。クライエントを変更する度にBFFを変更してデータの依存関係の解決や、GraphQLクエリの分解に相当することをやる必要が出てきます。最終的に面倒になって大きく安全側に倒したBFFができそうです。
ここで重要なことは、コンポーネントは常に自分がどんなデータを必要としているかを知っているということです。つまり、変更にBFFを追従させるには、どんなデータが必要か、更新対象のコンポーネントに尋ねればよいわけです。
そんなことは本当可能なのか?という気もしてしまいますが、Facebookはそれをやってのけてしまいました。それがReact Server Componentsです。今までのBFFがJSONを返していたところを、シリアライズされたReact.ReactNodeを返すことによって実現しています。ヤバい。